若還似從前那麼單純,恐怕在客戶手中早已死了幾百回了。
今天的內容相對的多,主要是為了建立更好的觀念,未來比較不會被AI騙(?)
看起來很數學的名詞,可以這樣理解,一個函式在遇到相同輸入的時候,輸出結果會相同,舉個例子:
現在有個函數我們叫他 f ,f 可以輸入一個 x 計算,輸出結果為 x + 1,就變成下面這樣,只要輸入相同的 x ,輸出結果就永遠都是一樣的。
f(x) = x + 1
代入 x = 2:f(2) = 2 + 1 = 3
代入 x = 5:f(5) = 5 + 1 = 6
再代入 x = 2:f(2) = 2 + 1 = 3
純函式有兩個重點:
來看看實際用在程式上會長怎麼樣吧,以學習卡片的剩餘天數來看:
function getRemainingDays(day: number, totalDays: number) {
return Math.max(totalDays - day, 0);
}
getRemainingDays(7, 30); // 23
getRemainingDays(7, 30); // 還是 23
getRemainingDays(31, 30); // 0
Math.max(..., 0) 是用來取兩個數字裡較大的,避免已經超過目標時,還顯示負的剩餘天數。
上面清楚了,回頭看之前的 LearningCard,其實我們已經寫過純函式:
type LearningCardProps = {
title: string;
topic: string;
day: number;
totalDays?: number;
};
function LearningCard({
title,
topic,
day,
totalDays = 30,
}: LearningCardProps) {
const remainingDays = Math.max(totalDays - day, 0);
const isCompleted = day >= totalDays;
return (
<section>
<h2>{title}</h2>
<p>今天練習 {topic}</p>
<p>目前進度:第 {day} 天 / 共 {totalDays} 天</p>
<p>{isCompleted ? "已完成" : "還在努力"}</p>
{remainingDays > 0 && <p>接下來還有 {remainingDays} 天</p>}
</section>
);
}
day 和 totalDays 是計算進度的輸入,remainingDays 和 isCompleted 是算出的結果,最後用 JSX 把結果放進畫面。前面數學範例回傳一個數字,這裡則回傳描述卡片內容的 JSX。
這個元件符合剛才的兩個重點:相同的 props 會得到相同的卡片內容,而且計算時只建立自己的區域變數(Local Variable),沒有修改 props 或外部資料。每次渲染都依照當次輸入重新計算,不會把上一次的進度累加進來。
重點再一次:
相同輸入,相同結果。
不修改呼叫前就存在的外部變數或物件。
下面用範例來說明計算畫面是什麼:
回到剛才的 LearningCard,這兩行都是根據 props 計算:
const remainingDays = Math.max(totalDays - day, 0);
const isCompleted = day >= totalDays;
傳入 day={7}、totalDays={30},就算出 remainingDays 是 23、isCompleted 是 false。JSX 再使用這些結果顯示「還在努力」和「接下來還有 23 天」。
算完以後,收到的 day 仍然是 7,原本那筆計畫的進度也還是第七天。
這就是計算畫面:讀取輸入,建立這次要用的結果。
那麼修改外部資料呢,接著看:
以原本的三筆計畫資料為例,假如在 Welcome 函式裡加上這一行:
// 錯誤示範:在渲染時修改函式外面的既有資料。
learningPlans[0].day += 1;
這次改到的是 learningPlans 第一筆物件本身。原本的第七天會被改成第八天,再執行一次又變成第九天;其他讀取這筆資料的元件,也會受到影響。
除了回傳計算結果,還修改了外部既有資料、寫入儲存空間或觸發其他外部動作,這些額外影響稱為副作用(side effect)。
現在問題來了,開發的時候怎麼知道現在這個是不是純函式,舉幾個元件內的動作來看:
| 元件裡的動作 | 判斷 |
|---|---|
用 day、totalDays 算剩餘天數 |
可以,根據輸入計算結果 |
用 filter() 算出有幾份計畫完成 |
可以,篩選條件只讀取資料 |
| 修改函式外的計數器,記錄元件執行幾次 | 不適合,結果會受到之前的渲染影響 |
直接改傳進來的 plan.day |
不適合,會改到呼叫者提供的物件 |
| 在元件本體送出請求、寫入儲存空間或直接修改 DOM | 這些會影響外部,應安排在渲染之外 |
判斷純粹性時,要一起檢查「相同輸入是否得到相同結果」與「計算是否造成外部影響」。與使用者操作有關的工作,通常放在事件處理函式;需要和外部系統同步時,才評估 Effect。
關於元件的純粹性,可以再看這裡:
React:Components and Hooks must be pure
React:Keeping Components Pure
可以修改這一次呼叫才建立、沒有改到外部資料的區域變數或物件。
區域修改看這邊:React:Local mutation
範例:
function TopicList() {
const topics = ["JSX", "props"];
topics.push("元件的純粹性");
return (
<ul>
{topics.map((topic) => <li key={topic}>{topic}</li>)}
</ul>
);
}
每次呼叫 TopicList 都會建立新的 topics,再加入一個主題,結果固定是三項。這三個主題不重複,這裡可以用主題文字當 key。
如果把 topics 搬到函式外面,卻把 push() 留在裡面,就會變成每次渲染都往同一個陣列加資料。方法名字一樣,改到誰的資料卻不同,那就是副作用(side effect)了。
props 要當成唯讀,是因為它是外層元件提供給這次渲染的輸入。 內層元件可以根據它計算結果,但不應直接改寫收到的資料。
沿用卡片的寫法,上層元件用 day={plan.day} 傳入進度,LearningCard 負責讀取並顯示。希望進度從第七天變成第八天,那就是把上層元件使用的資料,day 改成 8,存檔後讓元件根據新輸入計算結果出來。
唯讀仍然可以搭配變動的畫面:只要資料來源提供新的值,內層元件就可以收到新的 props,若要透過按鈕更新進度,就再接上事件與 state。
官方的說明看這邊:React:Props and state are immutable
目前的 day={plan.day} 傳入的是數字。如果改成傳整筆物件,或像後面我們要做的摘要 plans={learningPlans} 傳入陣列,內層元件收到的仍然是同一份資料的參照(reference),React 不會自動幫它複製一份。
因此,在內層元件裡直接改物件屬性或陣列內容,也會改到外層元件持有的那份資料。即使沒有立刻報錯,也不代表符合 props 唯讀的原則。
const 不是把整份資料凍起來,看範例:
{
const originalPlans = [{ id: "my-react", day: 7 }];
const copiedPlans = [...originalPlans];
copiedPlans[0].day = 8;
console.log(originalPlans[0].day); // 8,原本的物件也被改到了
}
[...originalPlans] 建立了新陣列,但裡面的物件仍然共用,這叫淺拷貝(shallow copy)。在元件內寫 const copiedPlans = plans; 也只是在替同一個陣列取別名,連陣列都沒有複製。
如果只是顯示剩餘天數,像卡片一樣計算新數字就好,真的需要一份調整過的資料時,可以建立新陣列,並替要調整的項目建立新物件,例如:
{
const originalPlans = [{ id: "my-react", day: 7 }];
const nextPlans = originalPlans.map((plan) => // 在這裡透過map()建立了新陣列
plan.id === "my-react" ? { ...plan, day: 8 } : plan,
);
console.log(originalPlans[0].day); // 7
console.log(nextPlans[0].day); // 8
}
{ ...plan, day: 8 } 先把原有欄位放入新物件,再設定新的 day。這裡只調整最外層的數字欄位;如果裡面還有巢狀物件,要改哪一層,就還要處理那一層的複製
。
淺拷貝可以看這篇:MDN:Spread syntax — Copying an array
後面還會學 state 和 context,也都是渲染時應當作唯讀的輸入。 (又挖坑)
好像有點太多,整理成表格比較清楚![]()
| 名詞 | 觀念 | 範例 |
|---|---|---|
| 純函式 | 相同輸入得到相同結果,計算時不產生可觀察的副作用 | 傳入第 7 天、共 30 天,每次都算出剩餘 23 天 |
| 副作用(side effect) | 計算之外,還改動了外部既有資料或觸發外部動作 | 渲染時執行 learningPlans[0].day += 1,讓原始進度跟著改變 |
| props 唯讀 | 內層元件讀取外層元件提供的資料,不直接改寫它 | 卡片用 day 計算剩餘天數,要更新進度就從資料來源處理 |
| 區域修改 | 每次呼叫都新建、沒有與外部共用的資料,可以在本次計算中修改 | topics 在元件內新建,再 push() 一個主題,不會累加到下一次 |
| 淺拷貝 | 複製陣列外層,裡面的物件仍可能共用 | [...originalPlans] 之後改物件的 day,原始物件也可能被改到 |
所以看到 push() 或賦值時,可以看它改到誰的資料。
改到外面既有的共用資料,讓其他元件或下一次計算可能受到影響 => 副作用
修改的是本次呼叫新建的資料,沒有改到外部共用物件 => 區域修改。
檢查元件時,請常念 輸入相同,畫面內容會一樣嗎?計算過程有沒有影響外面的資料或系統?
講這麼多,接著就用這兩個問題,建立學習摘要元件來驗證吧。
因為 Welcome.tsx 有點長,就改用片段示意了,參考下面做法。
在 Welcome 函式外面加入 LearningSummary,讀取 plans,算出已完成的計畫數量:
function LearningSummary({ plans }: { plans: LearningPlan[] }) {
const completedCount = plans.filter(
(plan) => plan.day >= plan.totalDays,
).length;
return <p>共 {plans.length} 份計畫,已完成 {completedCount} 份。</p>;
}
再到 Welcome() 回傳的 JSX,在原本的頁面標題下方加入:
<section>
<h2>全部計畫的摘要,算兩次看看</h2>
<LearningSummary plans={learningPlans} />
<LearningSummary plans={learningPlans} /> {/* 驗證相同輸入的結果是一樣的 */}
</section>

講這麼多純粹性,直接看錯誤示範會更明白為什麼要這麼做。
// 錯誤示範:這個變數被所有 LearningSummary 共用。
let completedCount = 0;
function LearningSummary({ plans }: { plans: LearningPlan[] }) {
completedCount += plans.filter(
(plan) => plan.day >= plan.totalDays,
).length;
return <p>共 {plans.length} 份計畫,已完成 {completedCount} 份。</p>;
}
上面的程式,累加回外層的數字了,來看看實際會發生什麼事。
很明顯的看出,多出了幾個已完成的幽靈數量,而且型別檢查也會通過,因為數字加數字完全合法。
不同的開發工具更新、額外渲染,以及這份專案的伺服器渲染,都可能影響執行次數。伺服器和瀏覽器也有各自的變數,可能看到不同數字,或 hydration 警告(伺服器先產生的內容和瀏覽器接手時算出的內容不一致)。
修正方式,渲染時不要修改函式外的共用變數,每次根據 props 重新計算即可。
把 let completedCount = 0 移除,LearningSummary 改成:
function LearningSummary({ plans }: { plans: LearningPlan[] }) {
const completedCount = plans.filter(
(plan) => plan.day >= plan.totalDays,
).length;
return <p>共 {plans.length} 份計畫,已完成 {completedCount} 份。</p>;
}
重新載入,確認兩行摘要都回到共 3 份計畫、已完成 1 份,就OK囉。
純粹性這件事情一開始很混亂,但懂了概念以及為什麼要這樣做之後,就可以明白一切都是為了保持畫面和資料的一致性。
本來還要做 StrictMode,但這樣太多了,會注意力渙散![]()
明天接著繼續講 StrictMode、事件處理。